Backend triggers: resolve dependencies by their real container name - #124
Backend triggers: resolve dependencies by their real container name#124antoncxx wants to merge 1 commit into
Conversation
59f0a21 to
2c271dc
Compare
f868bd5 to
a5deaa6
Compare
|
Thanks @antoncxx looks good, just two things:
|
|
|
Added config validation
This runs in two places that both call |
|
Let me know when this is ready. Also test in a setup with mixed proxy deps and backend triggers please, and verify there are no collisions or weird stuff happening with egress edges. Also at every new feature we should really be careful of avoiding regressions and that at network teardowns and nullnet-client restart, the state is properly cleaned up, I'm saying it's especially now because we're going in production |
Lets a service dial a backend dependency by its literal Docker container name (e.g. "dep-a") instead of a "*.nullnet.com" alias — nullnet-client pre-seeds a deterministic placeholder /etc/hosts entry for the name so a bare-name lookup produces a real first packet, NFQUEUE catches it on the declared trigger port, and the server brings up the real tunnel on demand. Supports multiple dependencies sharing one real port (TriggerMap is a list of chains per port, not one), disambiguated at trigger time by matching the observed destination against each candidate's own placeholder address (nullnet-grpc-lib::last_octet_for, shared by client and server so they can't disagree on the mapping). Config validation rejects both a literal chain[0] duplicate on a shared port and the rarer case where two distinct names hash to the same placeholder address, with a message identifying both names so the reason surfaces in the config UI, not just a generic parse error. Also includes three nullnet-client reliability fixes found while building and validating this against an end-to-end demo stack (since removed): - Stale TriggersState entries now self-heal on container restart/recreate (previously required a manual client restart) by hooking the existing docker-events watcher to purge a recreated container's trigger state. - The backend-trigger placeholder block is now exempted from egress/ country-policy classification, closing a startup race where a trigger dial landing before its port was watched got misrouted into the egress path and stalled past its own caller's timeout instead of failing fast. - nullnet-client now warns loudly, once per bad spell, when a managed container has no default route — the precondition this whole mechanism depends on — instead of the only symptom being a bare ENETUNREACH three layers away in the initiator's own app.
1fe4ccb to
be37274
Compare
Summary
Lets a backend-trigger dependency chain declare a literal Docker container
name (
chain = ["redis"]) instead of a purpose-built DNS alias(
chain = ["redis.nullnet.com"]). Also adds support for two dependenciesthat happen to share a real port (e.g. two plain-HTTPS deps, both 443),
which the first version of this change left as a known gap and later
closed out.
Why
Backend-trigger dependency chains only build the first time the initiator
actually opens a connection on the watched port — an NFQUEUE listener holds
that first packet, reports it to the server, the server builds the tunnel,
then the packet is released once DNAT is in place. That only works if a
real packet leaves the container in the first place. A bare name like
redisfails before it gets that far: with nothing to resolve it to, theapp's
connect()never succeeds, no packet is ever sent, and the triggernever fires. Today's workaround is a purpose-built alias
(
redis.nullnet.com) backed by a pre-provisioned*.nullnet.comDNSwildcard, whose only job is giving the name something to resolve to.
This PR removes that workaround: the client pre-seeds a placeholder
/etc/hostsentry for the dependency's literal name before any packet isobserved, so the bare name resolves, a real first packet gets sent, and the
existing NFQUEUE →
backend_trigger→ tunnel-setup → DNAT flow runsunchanged after that (it never inspected the destination address to begin
with).
What changed
Proto (
nullnet_grpc.proto)ServiceTrigger.trigger_ports:repeated TriggerPort { port, target_name }replaces the old flat
portslist, so the server tells the client notjust which port to watch but which literal name (
chain[0]) it resolves.ServiceTrigger.initiator_container: the real container name the clientshould seed, resolved server-side from data it already had.
BackendTriggerRequest.target_name: the client reports back which nameits placeholder's destination address belonged to, so the server can pick
the right chain when a port has more than one.
Server
ServiceInfo.triggersmoves fromHashMap<u16, Vec<String>>(one chainper port) to
HashMap<u16, Vec<Vec<String>>>— more than one chain canshare a port now. New
chain_for(port, target_name)selector: a singlechain on a port is used regardless of
target_name(unchanged behaviorfor every existing config); multiple chains require an exact match — no
guessing when ambiguous.
chain[0]outright (nothing could ever tell them apart).http_server/services.rs) and UI(
Services.tsx/types.ts) updated to the new per-port chain list.Client
placeholder.rs(new): deterministicname → IPin203.0.113.0/24(RFC 5737 TEST-NET-3 — reserved, never a real host, never on-link for a
container's own subnet, so it always falls through to the container's
default route). Range is env-overridable via
TRIGGER_PLACEHOLDER_CIDR.loop (same ~10s /
docker eventsfast-path cadence already used for thewatched-port ipset) — idempotent, self-heals across container restarts
(Docker wipes
/etc/hostson every start).TriggersStateand DNAT (commands/dnat.rs) both widened to key ondestination address as well as source + port, so two chains sharing a
port get independent state and independent
-d-scoped DNAT rules insteadof clobbering each other.
ipv4_flow,already existed for the egress listener) and, when a port has more than
one candidate target, disambiguates by matching the packet's observed
destination against each candidate's own deterministic placeholder
address — passing through unaltered rather than guessing if nothing
matches.
backend-trigger entries, so an idle-torn-down chain can re-trigger on the
next connection attempt instead of dead-ending exactly like an unseeded
bare name would. Proxy-dependency mappings are unaffected — a fresh proxy
request rebuilds the chain and writes the real mapping before forwarding,
so there's no gap to cover there.
### Demo (
demo/name-resolution/)Three-container stack (
portal→dep-a,dep-b) exercising the wholething end to end, reachable through
nullnet-proxybyHostheader.dep-aanddep-bdeliberately share the same real port (80) — twotrigger chains on one port, told apart by
chain[0]— so the demo actuallyruns the disambiguation path, not just the base case. Each container sits
on its own isolated Docker network: no shared network, no path between them
except the one nullnet builds on demand, which is what proves the
placeholder-seeded trigger is doing the work rather than Docker's own
embedded DNS resolving things directly.
Out of scope
resolving any name) — the backend-trigger path already handles this for
free (it never inspected the destination address), but proxy-dependency
edges would need the same DNAT fallback backend triggers have; not
addressed here.